14 WebSocket 客户端进阶与事件机制
WebSocket 客户端进阶与事件机制
关联:索引
要解决的问题
- 为什么很多 Demo 能“连上”,但在异常与边界场景下易出错(未就绪发送、错误未捕获、断线不提示)
- 客户端生命周期事件如何协同工作,readyState 状态如何精确判断并驱动 UI
- 如何用统一 JSON 格式组织通信消息,便于扩展、记录与排错
- 心跳机制是什么、何时需要、最简单的实现思路是什么
- 如何设计覆盖核心流程的基础测试用例,确保客户端稳定性
章节内容(本讲核心):
- 客户端生命周期事件:open、message、error、close 的职责与协作
- 状态判断:readyState(CONNECTING/OPEN/CLOSING/CLOSED)与 UI 联动
- 统一消息格式:JSON Envelope(type/id/ts/payload/meta)
- 简单心跳机制(概念):客户端定时 ping、服务端 pong
- 基础测试用例设计:连接、消息、错误、关闭、心跳覆盖
- AI 协同开发:生成标准消息结构体与状态判断代码
与前置知识衔接(避免重复):
-
已学:WebSocket 原理、握手与与 HTTP 的差异;基础客户端 + FastAPI 服务端 Demo
-
本讲不重复:握手细节与服务端架构;仅在需要时用最简服务端示例支撑心跳
-
本讲定位:聚焦“客户端进阶与事件机制”,提升健壮性与可维护性
-
生成标准消息结构体(TypeScript)与状态判断函数
-
输出最简心跳代码片段(客户端 ping,服务端 pong)
-
AI:结构体与工具函数生成、心跳示例与测试点清单
-
学生:整合到组件,完成联调与自测,对最终运行结果负责
- 未就绪发送:连接仍在 CONNECTING 就调用 send
- 错误未捕获:onerror 未处理、用户无提示
- 断线无提示:onclose 未处理、状态展示缺失
- 消息无格式:难以扩展与排错,日志不可读
1. 生命周期事件职责
- open:连接建立成功后触发,适合做“可发送”提示与初始化逻辑
- message:接收服务端消息,先尝试按统一格式解析;失败降级为原始文本
- error:连接或收发出现错误,记录并展示;通常伴随 close
- close:连接关闭(服务端主动或客户端关闭),应停止心跳与更新状态
2. readyState 状态机
- CONNECTING(0) → OPEN(1) → CLOSING(2) → CLOSED(3)
- UI 联动:仅在 OPEN 时允许发送;其余状态提示与禁用
- 建议:封装“状态映射”与“就绪判断”函数,避免散落逻辑
3. 统一 JSON 消息格式(Envelope)
推荐最小字段:
{
"type": "chat|ping|pong|system|error",
"id": "uuid-or-random",
"ts": 1710000000000,
"payload": {},
"meta": { "from": "client|server", "traceId": "" }
}
- 解析策略:优先按 Envelope 解析;失败则按文本展示并记录
- 降级策略:服务端尚未支持时,依然可发送文本;客户端解析容错
4. 心跳机制(概念)
- 目标:在长连接场景做“轻量活性探测”,避免静默断线
- 最简设计:客户端每 N 秒发送
type=ping,服务端回type=pong - 启停条件:仅在 OPEN 时发送;在 close/error 时停止
文件路径:client/websocket-demo/src/utils/ws.ts
// 消息类型:限定可用字符串,避免写错(例如把 'ping' 写成 'pign')
export type MsgType = 'chat' | 'ping' | 'pong' | 'system' | 'error'
// 统一消息 Envelope:所有消息都用同一层结构包起来,便于扩展与排错
// T:payload 的具体结构(不同 type 对应不同 payload)
export type Message<T = unknown> = {
// 消息类型(决定如何处理:聊天/心跳/系统/错误等)
type: MsgType
// 消息唯一 id(用于日志追踪、去重、定位某次交互)
id: string
// 时间戳(毫秒)
ts: number
// 业务载荷(真实数据内容)
payload: T
// 元信息(可选):标记来源/链路追踪等
meta?: { from?: 'client' | 'server'; traceId?: string }
}
// 构造统一消息:避免每次手写 id/ts/meta,保证格式一致
export function buildMessage<T>(type: MsgType, payload: T): Message<T> {
// randomUUID:现代浏览器可用;可选链避免在不支持时直接报错
// globalThis:比 window 更通用(浏览器/Node 都可用)
const uuid = globalThis.crypto?.randomUUID?.()
return {
type,
// 优先使用 uuid;否则回退用随机字符串(课堂 demo 足够)
id: uuid ?? Math.random().toString(36).slice(2),
ts: Date.now(),
payload,
meta: { from: 'client' }
}
}
// 将 WebSocket.readyState(数字)映射为可读文本,便于 UI 展示与状态判断
// readyState:0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
export type ReadyStateText = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'
export function mapReadyState(ws?: WebSocket): ReadyStateText {
// ws 可能为 undefined(尚未创建/已释放),用可选链读取
const s = ws?.readyState
// 未创建 ws 时 s 为 undefined,这里统一归为 CLOSED(UI 更好处理)
return s === WebSocket.CONNECTING
? 'CONNECTING'
: s === WebSocket.OPEN
? 'OPEN'
: s === WebSocket.CLOSING
? 'CLOSING'
: 'CLOSED'
}
- 在上一课基础上完善客户端:状态展示、格式化消息、错误捕获、心跳
- 复用既有 FastAPI 服务端;如需 pong,可临时加最简分支处理
文件路径:client/websocket-demo/src/components/WebSocketAdvanced.vue
<template>
<div>
<h3>WebSocket 客户端进阶</h3>
<!-- 状态展示:由 ws.readyState 映射而来(CONNECTING/OPEN/CLOSING/CLOSED) -->
<div>状态:{{ status }}</div>
<!-- v-model 把输入框内容绑定到 msg(响应式) -->
<input v-model="msg" type="text" placeholder="输入消息" />
<!-- 点击触发 send:内部会做“是否 OPEN”判断,避免未就绪发送 -->
<button @click="send">发送</button>
<!-- 日志区域:white-space: pre-line 让 \n 换行在页面上生效 -->
<div style="margin-top: 12px; white-space: pre-line;">{{ logs }}</div>
</div>
</template>
<script setup lang="ts">
/**
* 这一段组件做了几件事:
* 1) 建立 WebSocket 连接,并维护连接状态 status
* 2) 发送消息时统一包装为 JSON Envelope(buildMessage)
* 3) 接收消息时做“解析 + 校验”(parseMessage),解析失败则降级显示原始文本
* 4) 简单心跳:定时发送 ping(服务端可回 pong)
*/
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { buildMessage, mapReadyState } from '../utils/ws'
import type { Message, ReadyStateText } from '../utils/ws'
/**
* status:展示连接状态(字符串),由 mapReadyState 统一映射。
* 这里用 ReadyStateText 类型约束取值范围,避免写错状态字符串。
*/
const status = ref<ReadyStateText>('CONNECTING')
/**
* logs:把所有关键事件(连接成功/收到消息/错误/关闭)累积输出,便于课堂观察。
*/
const logs = ref('')
/**
* msg:输入框内容(待发送文本)。
*/
const msg = ref('')
/**
* ws:WebSocket 实例。
* - onMounted 创建连接
* - onBeforeUnmount 关闭连接
*/
let ws: WebSocket | null = null
/**
* heartbeat:心跳定时器句柄,用于组件卸载时清理。
*/
let heartbeat: number | null = null
/**
* log:向 logs 追加一行文本(带换行)。
*/
function log(s: string) {
logs.value += s + '\\n'
}
/**
* setStatus:刷新状态显示。
* ws 可能为 null,因此用 ws ?? undefined 传给 mapReadyState(参数是可选的)。
*/
function setStatus() {
status.value = mapReadyState(ws ?? undefined)
}
/**
* send:发送聊天消息(type=chat)。
* 关键点:
* - trim 后为空直接返回
* - 只允许在 ws.readyState === OPEN 时发送,避免 CONNECTING/CLOSED 时 send 抛异常
* - 统一用 buildMessage 包装成 JSON Envelope
*/
function send() {
const text = msg.value.trim()
if (!text) return
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('连接未建立')
return
}
const m = buildMessage('chat', { text })
ws.send(JSON.stringify(m))
log(`发送:${text}`)
msg.value = ''
}
/**
* parseMessage:把 unknown 数据“安全地”解析为 Message(Envelope)。
* 这是一个很典型的“运行时校验”函数:
* - 因为 JSON.parse 的结果类型是 unknown/any,不能直接当成 Message 用
* - 先验证:必须是对象、包含 type/id/ts
* - 再验证:type 必须属于允许集合,id/ts 的类型必须正确
* 校验通过才返回 Message,否则返回 null(表示“不是我们约定的协议消息”)。
*/
function parseMessage(data: unknown): Message | null {
if (typeof data !== 'object' || data === null) return null
if (!('type' in data)) return null
const type = (data as { type?: unknown }).type
const id = (data as { id?: unknown }).id
const ts = (data as { ts?: unknown }).ts
const payload = (data as { payload?: unknown }).payload
if (
type !== 'chat' &&
type !== 'ping' &&
type !== 'pong' &&
type !== 'system' &&
type !== 'error'
) {
return null
}
if (typeof id !== 'string') return null
if (typeof ts !== 'number') return null
return { type, id, ts, payload }
}
/**
* onMounted:组件挂载后建立连接,并绑定 WebSocket 事件。
*/
onMounted(() => {
// 建立连接(课堂 demo 默认 localhost:8000)
ws = new WebSocket('ws://localhost:8000/ws')
setStatus()
// open:连接建立成功
ws.onopen = () => {
setStatus()
log('连接成功')
}
// message:收到服务端消息
ws.onmessage = (e) => {
/**
* e.data 可能是 string / Blob / ArrayBuffer 等。
* 这里仅对 string 做 JSON.parse;非 string 直接走降级显示。
*/
let parsed: unknown = e.data
if (typeof e.data === 'string') {
try {
parsed = JSON.parse(e.data) as unknown
} catch {}
}
/**
* 先按“协议消息”解析:
* - 成功:按 type 分支处理(pong / error / 其他)
* - 失败:降级输出原始内容(String(e.data))
*/
const m = parseMessage(parsed)
if (m) {
if (m.type === 'pong') log('心跳回复:pong')
else if (m.type === 'error') log(`服务端错误:${JSON.stringify(m.payload)}`)
else log(`服务端:${JSON.stringify(m.payload)}`)
return
}
// 降级策略:无法识别为 Envelope,就直接展示原始内容
log(`服务端:${String(e.data)}`)
}
// error:连接或收发发生错误(通常随后会 close)
ws.onerror = () => {
log('连接错误')
setStatus()
}
// close:连接已关闭(服务端断开/网络变化/客户端主动 close)
ws.onclose = () => {
log('连接关闭')
setStatus()
}
/**
* 心跳:每 10s 发送一次 ping
* - 仅 OPEN 才发送,避免非法状态 send
* - 服务端若支持,会回 pong;客户端据此记录“连接仍活着”
*/
heartbeat = window.setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
const ping = buildMessage('ping', {})
ws.send(JSON.stringify(ping))
}
}, 10000)
})
/**
* onBeforeUnmount:组件卸载前清理资源
* - 清理心跳定时器
* - 如果连接仍处于 OPEN,则主动关闭(释放资源)
*/
onBeforeUnmount(() => {
if (heartbeat) {
clearInterval(heartbeat)
}
if (ws && ws.readyState === WebSocket.OPEN) ws.close()
})
</script>
入口文件与页面可复用上一课的 main.ts 与 index.html,将组件替换为 WebSocketAdvanced.vue。
入口文件路径:client/websocket-demo/src/main.ts
页面文件路径:client/websocket-demo/index.html
可选:服务端最简 pong 分支(FastAPI)
文件路径:server/app.py
from fastapi import FastAPI, WebSocket
import uvicorn, json, time
app = FastAPI()
@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
# 1) 接受握手:没有 accept() 就无法开始收发消息
await websocket.accept()
try:
while True:
# 2) 等待客户端发来文本消息
text = await websocket.receive_text()
try:
# 3) 优先按 JSON 协议解析
data = json.loads(text)
except:
# 4) 解析失败则降级为“文本回显”(兼容未上协议格式的客户端)
await websocket.send_text(text)
continue
t = data.get("type")
if t == "ping":
# 5) 心跳:收到 ping 回复 pong(最简可行)
await websocket.send_text(json.dumps({"type":"pong","ts":int(time.time()*1000),"payload":{}}))
elif t == "chat":
# 6) chat:示例做 echo,把 payload 原样回去(或包装成 echo 字段)
await websocket.send_text(json.dumps({"type":"chat","payload":{"echo":data.get("payload")}}))
else:
# 7) 其他 type:给一个 system 响应,避免客户端“没回包”
await websocket.send_text(json.dumps({"type":"system","payload":{"info":"ok"}}))
except Exception:
# 课堂 demo 简化:异常直接结束循环(生产环境应区分断开与错误并记录日志)
pass
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
| 测试点 | 操作与验证 | 预期结果 |
|---|---|---|
| 连接建立 | 启动服务端,打开客户端 | 显示“✅ 连接成功”,status=OPEN |
| 未就绪发送 | 在 CONNECTING 状态点击发送 | 拦截并弹出提示,不执行 send |
| 消息收发 | 输入文本并发送 | 客户端显示发送日志,收到服务端回复并记录 |
| 错误处理 | 关闭服务端后再发送 | 显示“❌ 连接错误/关闭”,status=CLOSED |
| 心跳行为 | 保持连接 ≥20s,观察日志 | 定时发送 ping,收到 pong 或文本降级 |
| 关闭清理 | 关闭页面或主动关闭连接 | 停止心跳、状态更新、无异常报错 |
工程化落地补充(可选)
- 代码规范:类型先行、状态枚举统一、日志带时间戳与类型标签
- 维护性:封装 WebSocket 管理器(连接、事件、发送、心跳、关闭)
- 扩展性:按 type 路由到具体处理器(chat/system/error 等)
- 测试意识:最小用例先行,覆盖异常与边界;联调用例可复用到 CI